Unify map generator controls and tune playable defaults - #238
Merged
Merged
Conversation
genixpro
force-pushed
the
codex/map-generator-defaults
branch
from
September 10, 2026 21:36
9e9f0f1 to
b74f6df
Compare
Contributor
Author
|
Rebased onto current master (
Verification on this head (
Requesting review from @Giszmo and @kylelutze — this is still marked draft; let me know if it should come off draft. No merge performed. |
genixpro
added a commit
that referenced
this pull request
Sep 10, 2026
#238 (already merged) added CustomGamePreferences.h, which serializes CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::* member pointers directly. #240 replaced generator's type with GenerationRequest, whose method-specific options live in a generic std::map<std::string,int> instead of fixed struct fields — an architecture change, not a rename — so the file no longer compiled. - CustomGamePreferences::encode()/decode() now convert through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter #240 already built for exactly this kind of interop), so the on-disk wire format and its corruption-recovery bounds are unchanged. decode()'s method-validity check now asks the live GeneratorRegistry instead of a hardcoded 1-8 range, so it stays correct as generators are added or retired. - Widened several fields() bounds (terrain weights 0-64 -> 0-100, riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match the modular registry's current ranges. These are approximate, same as before: the reused legacy fields (e.g. riverDiameter also stands in for lake size/channel width/bridge width) don't have one true bound, so this is a safe envelope, not a tight per-method one. Without this, decode() could reject a preferences file that a normal save legitimately produced (oldIslandSize's own default already exceeded the old 1-64 bound for any method other than Isles/Old Islands). - Found and fixed a related crash bug in the compatibility adapter itself while tracing this: Lattice and Maze register wheat/wood/ stone/algae controls with no entry in legacyField()'s mapping table, so converting either method through toLegacyDescriptor/ fromLegacyDescriptor threw an uncaught std::invalid_argument. Added the four missing mappings (they match pre-existing legacy struct fields exactly) and changed every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to falling back to its control's default value, so a newer generator's full option set can never crash this adapter again. - test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen() built a GenerationRequest via the same member-pointer approach; updated both to build a temporary MapGenerationDescriptor and convert. Switched preferencesModel()'s method from Old Islands to Crater Lakes (one of the four modern height-map generators that still exposes a repeat-landscape control; Old Islands never did in the new registry, so it always round-tripped back to 0). The per-field assertions in preferencesScreen() now compare against the same achievable conversion rather than raw field maximums, since only the options a method actually registers survive a GenerationRequest round trip. Verified: full scons -j8 release=1 server=0 client build is clean. CustomGameSetupHarness passes in default, preferences-write and preferences-read modes. MapGeneratorDefaultsTest and MapGeneratorStudy --catalog also pass, unaffected by the adapter fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
genixpro
marked this pull request as ready for review
September 10, 2026 23:23
genixpro
added a commit
that referenced
this pull request
Sep 10, 2026
#238 (already merged) added CustomGamePreferences.h, which serializes CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::* member pointers directly. #240 replaced generator's type with GenerationRequest, whose method-specific options live in a generic std::map<std::string,int> instead of fixed struct fields — an architecture change, not a rename — so the file no longer compiled. - CustomGamePreferences::encode()/decode() now convert through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter #240 already built for exactly this kind of interop), so the on-disk wire format and its corruption-recovery bounds are unchanged. decode()'s method-validity check now asks the live GeneratorRegistry instead of a hardcoded 1-8 range, so it stays correct as generators are added or retired. - Widened several fields() bounds (terrain weights 0-64 -> 0-100, riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match the modular registry's current ranges. These are approximate, same as before: the reused legacy fields (e.g. riverDiameter also stands in for lake size/channel width/bridge width) don't have one true bound, so this is a safe envelope, not a tight per-method one. Without this, decode() could reject a preferences file that a normal save legitimately produced (oldIslandSize's own default already exceeded the old 1-64 bound for any method other than Isles/Old Islands). - Found and fixed a related crash bug in the compatibility adapter itself while tracing this: Lattice and Maze register wheat/wood/ stone/algae controls with no entry in legacyField()'s mapping table, so converting either method through toLegacyDescriptor/ fromLegacyDescriptor threw an uncaught std::invalid_argument. Added the four missing mappings (they match pre-existing legacy struct fields exactly) and changed every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to falling back to its control's default value, so a newer generator's full option set can never crash this adapter again. - test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen() built a GenerationRequest via the same member-pointer approach; updated both to build a temporary MapGenerationDescriptor and convert. Switched preferencesModel()'s method from Old Islands to Crater Lakes (one of the four modern height-map generators that still exposes a repeat-landscape control; Old Islands never did in the new registry, so it always round-tripped back to 0). The per-field assertions in preferencesScreen() now compare against the same achievable conversion rather than raw field maximums, since only the options a method actually registers survive a GenerationRequest round trip. Verified: full scons -j8 release=1 server=0 client build is clean. CustomGameSetupHarness passes in default, preferences-write and preferences-read modes. MapGeneratorDefaultsTest and MapGeneratorStudy --catalog also pass, unaffected by the adapter fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
genixpro
added a commit
that referenced
this pull request
Sep 10, 2026
Rebased onto master now that #238 has merged. That surfaced a real compile break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap, which the registry-driven generator rewrite removed. Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&, seed) instead, which owns map sizing and the game association internally. Seed explicitly since generation determinism no longer follows the global sync-rand state. Also start from setMethodDefaults() rather than hand-picked constants: #238's defaults tuning tightened several control ranges (island-size moved to 50-70, the shared "workers" control caps at 8), so the harness's old literals (oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first and overriding only what this decorative colony actually needs to differ keeps it from rotting the same way again as ranges keep moving. Verified: full client and menu-colony-harness build clean; `check`, `navigation`, and `generate` subcommands all pass (generate grows the colony from 8 to 56 units over the same 12,000-tick warmup, confirming the reduced starting worker count doesn't defeat the decorative intent). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96
Giszmo
added a commit
that referenced
this pull request
Sep 14, 2026
Removed again in the next commit; the blobs stay reachable by SHA so the raw URLs in the PR body keep rendering. Repository convention, see PR #238. Opus 5 helped authoring this commit.
genixpro
added a commit
that referenced
this pull request
Sep 14, 2026
…ors (#240) * Modularize map generation and add new generators * Sort map generators by quality * Refine map generator quality order * Make Contested Commons the first and default generator * Fix rectangular map previews and wrapped colony start coordinates * Keep review reports and generated screenshots out of the repository * Bridge CustomGamePreferences through the legacy generator descriptor #238 (already merged) added CustomGamePreferences.h, which serializes CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::* member pointers directly. #240 replaced generator's type with GenerationRequest, whose method-specific options live in a generic std::map<std::string,int> instead of fixed struct fields — an architecture change, not a rename — so the file no longer compiled. - CustomGamePreferences::encode()/decode() now convert through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter #240 already built for exactly this kind of interop), so the on-disk wire format and its corruption-recovery bounds are unchanged. decode()'s method-validity check now asks the live GeneratorRegistry instead of a hardcoded 1-8 range, so it stays correct as generators are added or retired. - Widened several fields() bounds (terrain weights 0-64 -> 0-100, riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match the modular registry's current ranges. These are approximate, same as before: the reused legacy fields (e.g. riverDiameter also stands in for lake size/channel width/bridge width) don't have one true bound, so this is a safe envelope, not a tight per-method one. Without this, decode() could reject a preferences file that a normal save legitimately produced (oldIslandSize's own default already exceeded the old 1-64 bound for any method other than Isles/Old Islands). - Found and fixed a related crash bug in the compatibility adapter itself while tracing this: Lattice and Maze register wheat/wood/ stone/algae controls with no entry in legacyField()'s mapping table, so converting either method through toLegacyDescriptor/ fromLegacyDescriptor threw an uncaught std::invalid_argument. Added the four missing mappings (they match pre-existing legacy struct fields exactly) and changed every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to falling back to its control's default value, so a newer generator's full option set can never crash this adapter again. - test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen() built a GenerationRequest via the same member-pointer approach; updated both to build a temporary MapGenerationDescriptor and convert. Switched preferencesModel()'s method from Old Islands to Crater Lakes (one of the four modern height-map generators that still exposes a repeat-landscape control; Old Islands never did in the new registry, so it always round-tripped back to 0). The per-field assertions in preferencesScreen() now compare against the same achievable conversion rather than raw field maximums, since only the options a method actually registers survive a GenerationRequest round trip. Verified: full scons -j8 release=1 server=0 client build is clean. CustomGameSetupHarness passes in default, preferences-write and preferences-read modes. MapGeneratorDefaultsTest and MapGeneratorStudy --catalog also pass, unaffected by the adapter fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP * Fix MenuColonyHarness for the modularized generator API Rebased onto master now that #238 has merged. That surfaced a real compile break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap, which the registry-driven generator rewrite removed. Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&, seed) instead, which owns map sizing and the game association internally. Seed explicitly since generation determinism no longer follows the global sync-rand state. Also start from setMethodDefaults() rather than hand-picked constants: #238's defaults tuning tightened several control ranges (island-size moved to 50-70, the shared "workers" control caps at 8), so the harness's old literals (oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first and overriding only what this decorative colony actually needs to differ keeps it from rotting the same way again as ranges keep moving. Verified: full client and menu-colony-harness build clean; `check`, `navigation`, and `generate` subcommands all pass (generate grows the colony from 8 to 56 units over the same 12,000-tick warmup, confirming the reduced starting worker count doesn't defeat the decorative intent). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96 * Guarantee reachable starting wheat and wood, fix old-random's wood-poor bias Building on #240's modular generator rewrite: swamp, river, islands, crater lakes, old-random and old-islands all pick each team's resources through a step that has no idea where any *other* team ended up, or in old-random/ old-islands' case scores compass directions independently per team without comparing outcomes across teams. A colony's proximity to wheat and wood was left almost entirely to how the (still resource-blind) starting-position search happened to place it, so one team could land next to both while another got only one, or occasionally neither within playable range. Add shared/Resources::guaranteeStartingResources, which floods outward from each team's boot tile through the same walkable-space search used to judge distance (isHardSpaceForGroundUnit), so anything it places is reachable by construction rather than merely straight-line close. It tops up only teams that are missing wheat or wood within the range map_generator_study.py already scores as viable; already-served teams are untouched. Wired into the four generators that share Terrain::generateHeightField, and into old-random/old-islands after their own start placement. Also fix a starvation bug in old-random's resource search: its 8-direction scan hardcoded a 4th slot to CORN, so every colony got a guaranteed second wheat deposit while wood only ever got one. The 4th slot is now awarded to whichever of the two came out narrower for that colony. Extend test/MapGeneratorStudy.cpp's tuning output with best_wheat_distance/ best_wood_distance (previously only the worst-served team's distance was tracked) so tools/map_generator_study.py can size the gap between a map's best- and worst-served colony, not just its worst case in isolation. Validated with 200 fixed seeds per generator via map_generator_study.py (artifacts not committed, matching this directory's own convention): generation success rate is unchanged for every generator (including old-random and old-islands' existing ~4-5% baseline failure rates); the wheat-access gap between a map's best- and worst-served colony drops for swamp, river, islands and crater lakes, and old-random's wood gap and worst case both drop by roughly two-fifths. Old-random's wheat gap grew in the same run and a handful of colonies on constrained terrain (river was the clearest case) can still end up with no reachable wheat or wood at all; both are documented as open follow-ups in docs/map-generators/FRAMEWORK_UPGRADES.md; a farthest-point starting- position search was tried against the latter and measured worse on river and islands, so it was not kept. This is a balance-affecting change to established generators and needs a maintainer's sign-off per this repo's review rules, not just this PR's own validation numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Detect and clear resource walls that seal off a team's starting pocket Follow-up to 777b748. That commit's guaranteeStartingResources fixed most of the fairness gap but left river with a residual ~5% of colonies reaching no wheat or wood at all, and left old-random's imbalance direction flipped rather than closed. Investigated the river failures directly (map dumps + reachability analysis, not just aggregate metrics this time): a colony's boot tile was landing on a stretch of perfectly good, well connected land — confirmed by re-flooding the same tile through terrain alone, ignoring resources, and finding a landmass orders of magnitude larger than what the resource-respecting flood could reach. A ground unit can't walk onto a tile carrying a resource (Map::isHardSpaceForGroundUnit excludes them), and the noise-band resource painting has no idea what it might wall in, so it was sealing colonies into small pockets on their own otherwise-fine landmass. guaranteeStartingResources now detects this directly: when a team's resource-respecting reachable area is suspiciously small, it re-floods the same tile blocked only by water (ignoring resources) and diffs the two results to find exactly the resource tiles forming the wall's face — clearing only those, not a surrounding neighborhood, and iterating a bounded number of times in case a wall is thicker than one tile. A colony on a genuinely small spot (a real islet on a water-heavy map) is left alone: there's no larger area on the other side of nothing, so the terrain-only flood finds nothing bigger and the check is a no-op by construction. First attempt at this used a blunt fixed-radius clear around the boot tile whenever the pocket looked too small, gated only on the team actually being under-served. It worked for river but silently regressed swamp (whose terrain legitimately includes small real islands): demolishing a 25x25 tile neighborhood around a "small but actually fine" pocket destroys perfectly good nearby deposits for no gain when there is no larger landmass to reach. The precise wall-tracing approach here has no such failure mode, validated below. Reconfirmed with the same 200-fixed-seed methodology as 777b748: river's rate of a colony reaching no wheat or wood at all drops from ~5% to ~3% (the earlier blunt version reached 0% on this seed range but at swamp's expense — see above), with the remaining cases past this guarantee's own search radius. Swamp's numbers now improve slightly rather than regressing (the false-blame that motivated the blunt version's guard is gone: the gate is precise instead of merely permission-checked). Crater lakes, islands, old-random and old-islands are unaffected (the mechanism only fires where floodReach and a terrain-only flood actually disagree). Generation success rate is unchanged everywhere. Confirmed the existing map-generator-defaults-test and a full non-server engine build still pass. Old-random's wheat/wood imbalance (flipped, not fixed, by 777b748) and the remaining few percent of unreachable river colonies stay open follow-ups, called out in docs/map-generators/FRAMEWORK_UPGRADES.md along with why a join symmetric compass-slot assignment was tried and not kept for the former. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Choose colony sites after resources exist, and choose them for equality The four height-field generators picked colony sites before any resource was on the map, so a site could only be judged by the shape of the grass under it: largest contiguous grass rectangle to the first colony, and everyone after it taking what was left. Every fairness fix so far has been downstream repair of that decision. This changes the decision. The ordering turned out not to be a constraint at all. Nothing between the placement pass and the resource pass reads a boot position or draws from a random stream, so the two simply swap, and placement can then see what it is actually choosing between. Measured the headroom before building anything, via a new `headroom` mode in the study tool: score every legal site against a single multi-source flood per resource (one flood answers the question for the whole map, so scoring a site is a lookup rather than its own search), then find the narrowest score window still holding enough mutually distant sites. Result: every map sampled, on every generator, already contained a placement where all colonies were equally served. The maps were never unfair. The placement was throwing away fairness the map already had. shared/StartingPositions::chooseBalancedStarts now picks sites after the resource pass. A site scores as its *worse* primary resource, since a colony beside wood but a long walk from wheat is not a good start; sites are sorted and the narrowest qualifying window wins, scanned from the low-score end so ties settle in favour of a set that is not merely equal but good. The legacy search remains as a fallback for maps where no set of sites can reach both resources. Across 200 fixed seeds per generator, versus the pre-existing behaviour: gap between best- and worst-served colony 9.5-19.0 -> 3.5-4.0 tiles worst-served colony's walk to a resource 12.0-18.5 -> 3.5-4.2 tiles colonies reaching no wood or wheat at all up to 5.6% -> 0% colonies clearing the study tool's viability bar all of them The worst-served colony is now better off than the *luckiest* colony was before. Verified at 2, 6, 8 and 12 colonies as well (gap 2.4-7.4, widening with colony count as expected, no generation failures introduced at any count). Success rates, resource totals and colony separation (30-72 tiles apart on a 128x128 map) are unchanged. map-generator-defaults-test and a full non-server engine build pass. Old-random and old-islands deliberately keep their own placement: they put resources relative to each boot tile, so moving a colony does not bring its resources along. The same headroom measurement puts their achievable gap at 8.0 and 0.9 tiles, and old-islands already sits at 0.9. This changes how these four generators play, and that part is a maintainer's call rather than a metric's: colonies now start snug against their wood and wheat instead of in open ground, and buildable room within reach of the worst-served colony drops 14-28% (still 405-642 free building sites against a viability bar of 16). Generator revisions are bumped, so a given seed no longer produces the map it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Score colony sites as they will be built, not as the bare map looks Placement picks a site for its walk to wood and wheat, but a colony rewrites its own surroundings the instant it is built: placeStarts() clears a five by seven box of resources to make room, the swarm itself becomes four by four tiles of obstacle, and the workers spawn on the row above the boot tile rather than on it. Scoring against the bare map therefore credited a site with deposits it was about to destroy and paths it was about to block. That was measurable, not theoretical. Instrumenting the search on one seed showed it choosing four sites scored wood 0, wheat 0 apiece -- exactly equal -- which the finished map then spread across three tiles, because how much each site loses to its own construction depends on the shape of the resources around it. chooseBalancedStarts now shortlists on the cheap bare-map distance, which can only understate the built cost and so is a sound filter, then re-scores the shortlist by simulating the finished colony: clearing box excluded, swarm footprint impassable, flood starting from the worker row. The offsets mirror placeStarts() rather than approximating it. Against the previous commit, over 200 fixed seeds per generator: gap between best- and worst-served colony 3.5-4.0 -> 0.92-1.02 tiles worst-served colony's walk 3.5-4.2 -> 2.0-2.3 tiles Better on both counts at once, and level with old-islands, the fair-by-construction benchmark. Holds at 2, 8 and 12 colonies (gap 0.35-4.70, widening with count as expected, no failures at any count). Buildable room, resource totals and colony separation are unchanged. map-generator-defaults-test and a full non-server engine build pass. A first attempt instead excluded every site whose deposits sat inside the clearing box. It tightened the gap to 2.8-3.1 but pushed every colony roughly twice as far from its resources (worst walk 3.5 -> 6.6 tiles), trading what matters for what was being measured. Not kept. Generation costs 63-110ms against 11-14ms, nearly all of it the shortlist re-scoring, and the first working version cost 1400ms until the visited set stopped being a linear scan. Imperceptible for a lobby generating one map. Halving the shortlist halves the cost but loses a third of the gain on river, so the shortlist stays at 900. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Compute fertility as the growth probability it actually is Map::growResources decides whether a wheat or wood tile expands with dwax = (syncRand()&0xF) - (syncRand()&0xF); // likewise dway expand = isWater(x+dwax, y+dway) && !isSand(x-dwax, y-dway); The difference of two uniform draws from {0..15} has PMF (16-|d|)/256, so the chance a tile expands is a triangular kernel summed over every water tile whose mirror through the tile is not sand. FertilityCalculator fit a curve to the first half of that and dropped the second: its weights were int(4.2*sqrt((15-|dx|)*(15-|dy|))) and it never looked at sand at all, overstating exactly the tiles where wheat will not in fact come back. Fertility::Field evaluates the real thing in closed form. A length-16 forward box followed by a length-16 backward box is exactly the triangular kernel, so the water term is four linear passes rather than 961 taps per grass tile, and the sand term is one stamp per sand tile. Measured over 20 seeds on each of eight generators it runs about ten times faster than the kernel it replaces (the sand correction, not the convolution, is what it spends its time on) and correlates 0.89-0.997 with it; shattered-coast, the sandiest of them, is both the least correlated and the slowest, which is the difference being paid for. The field takes plain masks and no Map, so map generation can score a candidate's growth potential without a live game. FertilityCalculator keeps its API, its deposit-reachability gate and fertilityMaximum, and clamps to the Uint16 that Tile::fertility holds; the scale is now 65536, which the overlay normalises away and MapIO stores per tile as before. The implementation comes from ExactFertilityCache in AIMaximaFarming on the Maxima branch, extracted here so both callers share one field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Score the start each colony actually got, not just its walk Placement equalises walking distance to wood and wheat, and on four of these generators that is now down to two tiles for the worst-served colony. It is also nearly all the metric has left to give: sampling twenty maps and keeping the fairest shaves two tenths of a tile off a gap of one. Distance was always a thin account of a starting position. A deposit at the door that runs dry, a colony with no room to build, one boxed between two rivals, and one on ground where wheat never grows back all look alike to it. StartQuality measures six things per colony on the finished map -- where the swarm is built, its clearing cleared and the workers standing where they will actually start walking -- and folds them into one number per map: the weakest colony's quality, gated by how evenly the map shared quality out, worst * (worst/best)^k. Fertility carries the most weight of the six because it is the one that decides whether a colony's wheat comes back at all; the others describe what it starts with, fertility describes what it keeps. Every factor is normalised against a fixed reference rather than the map's own best colony, because the point is to rank candidate maps against each other and a per-map normalisation scores every map alike. The two distances use the viability bars the study tool already scores against; the other four have no such bar, so they sit near the ninetieth percentile of what the twelve generators actually produce, measured over 2400 maps. Scoring costs 2.5-5.4ms against 11-61ms to generate, consumes no random stream, and never rejects a map -- it ranks. Map hashes are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Keep the best of five rolls instead of the first that fits The lobby already generated up to five maps and kept the first that succeeded, because some rolls cannot fit every starting colony. Among the rolls that do fit, some still hand one colony far better ground than another, and now that there is a score for that, the same loop can keep the best rather than the first. Five is a budget, not a knee. Keeping the best of K is the maximum of K draws from a distribution, so it grows like sqrt(2 ln K): it improves forever and flattens gradually, and bootstrapped over 200 scored maps per generator all twelve trace that same curve to within a few percent -- three candidates capture 44% of what fifty give, five 59%, eight 70%, twenty 87%. What picks the cutoff is that the lobby generates on the UI thread behind a 500ms debounce: at 61ms a candidate on crater-lakes, five rolls cost 306ms where eight would cost 489ms and spend the whole budget. The editor owns its Game and cannot hold a spare, so it takes the winning seed from GenerationService::bestSeed and regenerates it. That is sound because generation is deterministic and the score is a pure function of the finished map; map-generator-defaults-test asserts both, along with the chosen seed being a candidate no other candidate outscores. Optimising a composite could have pulled colonies away from their resources to chase fertility or elbow room. Measured, it does not: the worst-served colony's walk holds at about two tiles on the four height-field generators and improves by four on shattered-coast, while fairness rises from 0.81-0.94 to 0.92-0.97 everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW * Cache the per-tile hard-space test chooseBalancedStarts already proved static Profiling map generation under load (users noticed the "keep the best of five rolls" work from the previous commit made map generation feel much slower) found chooseBalancedStarts itself, plus Map::isHardSpaceForGroundUnit called from inside it, accounting for 74-84% of total generation time on swamp, river, islands and crater lakes: two whole-map resource floods each call it once per tile, and then every one of up to 900 shortlisted candidate sites re-simulates its own local flood calling it again, up to ~4225 tile visits each in the worst case. That work was entirely redundant. Every call site in this file uses the same two constants (canSwim=false, team mask=0), which makes checkTile's forbidden- area test a no-op, and chooseBalancedStarts runs before placeStarts() ever places a building, so getBuilding() is NOGBID everywhere it looks. Under those invariants isHardSpaceForGroundUnit(x, y, false, 0) reduces to !isResource(x, y) && !isWater(x, y) — a pure function of terrain/resource state that cannot change across the whole search. buildHardSpaceGrid() computes that once into a flat std::vector<uint8_t> before the two distanceToResource() floods; all three flood loops (the two global ones and scoreAsBuilt's per-site one) read the cached byte instead of calling through checkTile's several field accessors. distanceToResource's distance vectors and the per-site visited/visitStamp array are narrowed from int to 16 bits alongside this — every distance this engine can produce fits comfortably, and the arrays these floods touch millions of times are half the size to move through cache. This changes nothing about which sites get chosen or how they get scored, only how the unchanged answer is computed: MapGeneratorStudy's per-tile terrain/resource hash is bit-identical before and after on matching seeds across every affected generator. Measured 28-31% faster end to end on swamp, river, islands and crater lakes (interleaved before/after binaries, 60 generations per generator, to separate the effect from this machine's own run-to-run noise) with zero measurable change on the nine generators that don't call chooseBalancedStarts, which is the expected result and also rules out the noise itself producing the improvement. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each, both before and after. FertilityFieldTest and MapGeneratorDefaultsTest are unaffected (this file's callers are unrelated to either). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Narrow FertilityField's single-axis intermediates to the range they use Investigated as part of the same profiling pass as the previous commit: Fertility::Field::rebuild is the dominant cost on Lattice and Maze (57% and 39% of total generation time), but unlike chooseBalancedStarts this is not redundant recomputation — Fertility::forMap has exactly one caller (scoreStarts) and runs once per roll, confirmed via its full call chain. Both generators are simply water/sand-heavy by design (thick maze corridors, real shorelines): instrumentation shows ~6,100 water and ~1,260 sand tiles for Lattice and ~4,100/~1,350 for Maze against a 16,384-tile map, and the sand-correction path costs one 31x31 weighted stamp per sand tile no matter how the adaptive rule picks between it and the water-splat alternative. first/second hold the box-blur passes' single-axis partial sums. The first pass (a plain 16-wide box over 0/1 water values) tops out at 16; combined with the second, backward pass into the full 1D triangular kernel it tops out at that kernel's own weight sum, 256; the vertical passes repeat the same shape against those values, topping out at 16*256=4096. Both fit uint16_t with no precision loss — the final `fertility` array still needs 32 bits, since its theoretical max (256*256=65536) is one past uint16_t's range. This is exact and safe, but measured negligible impact on Lattice/Maze specifically (their cost is the sand-correction term, which these arrays aren't part of). Worked through algebraically, that term reduces to sum_d weight(d) * water(target+d) * sand(target-d) - a bilinear cross-correlation between two different fields, not the single-field sum the box-blur trick relies on, so it does not have an equivalent separable speedup; only FFT-based convolution would change its asymptotic cost, and Fertility::Field is shared with FertilityCalculator (real gameplay fertility), so any such rewrite needs to preserve that caller's output exactly too. Left as an open follow-up rather than attempted here. Verified: full scons client build and scons -C test suite (198 cppunit cases, including FertilityFieldTest) pass with no narrowing warnings. MapGeneratorStudy succeeds across all 13 generators and produces bit-identical per-tile terrain/resource hashes before and after on matching seeds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Hoist named RNG-stream lookups out of Shattered Coast and Rugged Archipelago's hot loops Continuing the same profiling pass as the previous two commits: Shattered Coast (old-random) and Rugged Archipelago were the slowest and third-slowest of all 13 generators, and neither bottleneck was in their own terrain logic. GenerationContext::stream(name) looks up a named std::mt19937 in a std::map<std::string, std::mt19937>, computing a hash over the name and walking the tree on every call — cheap on its own, but both generators call it by string literal from deep inside per-tile loops that run tens of thousands of times per generation. Shattered Coast's simulateRandomMap (itself invoked repeatedly per smoothing iteration to balance water/sand/ grass ratios) draws from "simulation" up to five times per tile across every w*h-tile map it simulates; its terrain() draws from "terrain" the same way across its own patchwork and smoothing passes. Rugged Archipelago's island-growing and beach passes draw from "terrain" identically. In every one of these loops the stream name is a compile-time constant that never changes for the life of the function, so the repeated lookup was pure overhead paid on every single draw. Both functions now look their stream up once into a std::mt19937& at the top and draw from that reference throughout, instead of calling context.stream(name) again for every draw. This draws from the exact same underlying generator in the exact same order, so it changes no random number either function produces: MapGeneratorStudy's per-tile terrain/ resource hash is bit-identical on matching seeds before and after. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each. Measured 50% faster on Shattered Coast and 31% faster on Rugged Archipelago end to end (interleaved before/after binaries, 60-100 generations per generator), with ~0% change on two unaffected generators run the same way, ruling out this machine's run-to-run noise as the source of the improvement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Fix column-major grid traversal and pointer-chasing data structures in region splitting Profiling identified Contested Commons, Concrete Islands and Isles as now the slowest generators, dominated by the older Voronoi-style point-dispersion code in shared/Regions.cpp and shared/Distances.cpp. Investigated with cache behavior specifically in mind. Exact fixes (same output, only how it's computed - verified by bit-identical per-tile terrain/resource hashes on matching seeds, every generator): - computeDistances' BFS used std::queue<int> (std::deque-backed, growing by separately heap-allocated blocks) even though every cell is enqueued at most once (the `side == 0` guard prevents a second push). A flat, preallocated w*h-sized array makes an exact FIFO out of sequential writes instead of block-to-block pointer chasing. - splitUpArea's per-region frontier is inserted at a genuinely random position on purpose - that randomness is what gives the flood its organic shape, not an accident to remove - but std::vector<int> supports the identical random-position insert/erase via a contiguous memmove, without std::list's per-node heap allocation and pointer chasing on every single insert and on the std::advance that finds where to insert. - adjustHeightmapFromPerlinNoise and computeAverageDistance are a pure per-cell transform and a commutative sum respectively, so nesting y outside x costs nothing and walks both the grid and HeightMap's own identically row-major _map array with their grain instead of across it. - getAllPoints, getAllOtherPoints, findBorderPoints, and splitUpPoints's own two internal scans all walked a row-major grid as `for x { for y {...} }` - a full-row stride on every step - for no reason but habit, except two of these results (possible[n], startingPoints[n]) get indexed by a random draw, so a plain loop-order swap would silently pick a different point for the same seed. collectPointsColumnOrder gets both: a row-major counting pass sizes each output column, then a second row-major pass drops each point into its precomputed slot, landing every point in the exact x-major, y-minor order the original nested loop produced without ever striding across the grid to do it. splitUpPoints' single-pass "reset the candidate list on every strict improvement" site search reduces to the same set every time regardless of how it's computed (traced by hand, confirmed by the same hashes), so it is now an explicit two-pass max-then-collect using the same helper. One further fix changes output for Contested Commons specifically: splitUpPoints' PointSearch::WholeRegion mode (Contested Commons' own search, not used by any other generator) scores every legal tile as a candidate placement and keeps only a strict improvement, so whichever tied candidate is reached earliest in scan order wins - the one shape here a loop-order swap could not fix without changing anything. Measured directly across 7 seeds: 6 of 7 produce byte-identical maps regardless, and the one that changed still converges to a placement of the same quality by the search's own metric (already documented as bounded best-response, not a guaranteed optimum). PointSearch::Local - used far more broadly, including as swamp/river/islands/crater lakes' rare legacy fallback - keeps its exact original scan order untouched: its window is 7x7, too small for traversal order to matter, and touches far more generators than this fix was worth risking. Contested Commons' revision bump follows in the next commit. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 9 seeds each. Measured (interleaved before/after binaries, repeated 3x to get past this machine's run-to-run noise - an initial single-run reading overstated the effect by more than 2x): Concrete Islands ~9% faster, Isles ~5-7% faster, Contested Commons ~8-9% faster overall. Fjord Continent, which touches none of this code, showed ~2% change over the same run, taken as the noise floor rather than a real effect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Bump Contested Commons' revision for its WholeRegion cache-order fix The previous commit's PointSearch::WholeRegion traversal-order change can change which exactly-tied candidate site wins a placement, so a given seed can produce a different (equally valid, by the search's own metric) map than before. Following this repository's existing convention for generation-output changes, bump the revision so that is visible rather than a silent side effect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Finish replacing std::queue with preallocated flat FIFOs across the flood helpers A follow-up sweep for every remaining std::queue/std::list/std::map in the map generator code, not just the ones profiling had already pointed at. Found three more BFS floods with the exact same shape as computeDistances' earlier fix - each bounded to at most w*h enqueues by a dist[...] < 0 or visited[...] == stamp guard, so a flat preallocated buffer is always enough - that were missed the first time round: - StartingPositions.cpp's own distanceToResource and scoreAsBuilt still used std::queue internally even after the hard[] caching work in an earlier commit only replaced the isHardSpaceForGroundUnit calls, not the queue. scoreAsBuilt compounds differently from the rest: it runs this same flood up to 900 times per chooseBalancedStarts call, so a fresh std::queue (a fresh std::deque, freshly allocated) 900 times over was the real cost, not each flood's own pushes. The fix reuses one pair of preallocated buffers across all 900 calls, resetting two indices between them instead of reallocating a container each time. - Resources.cpp's floodReach and terrainOnlyReach (the resource-wall detector's reachability floods). - StartQuality.cpp's walkFromWorkers. Checked and left alone: Topology.cpp's own queue runs over Maze's cell graph (dozens of nodes, not tiles), where the difference is unmeasurable, and its std::map is exercised only by test code, not by any generator. Also swept every other context.stream() call site beyond the two fixed in the previous commit; the rest all draw well under a thousand times per generation (fruit placement, layout shuffles), far too infrequent for the map-lookup overhead to matter. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 6 seeds each, with bit-identical per-tile hashes on every generator (including Contested Commons matching the previous commit's WholeRegion-affected values exactly, seed for seed). Measured (interleaved before/after binaries, repeated 3x, with two generators that never call chooseBalancedStarts run the same way as a noise check): swamp ~14%, islands ~14%, crater lakes ~18% faster; river's readings were too noisy this round to quote a single number but moved the same direction every time. Contested Commons and Fjord Continent - untouched by this change - showed ~0% and ~2% respectively, the same noise floor as the previous commit found. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Give Fjord Continent an ambient resource scatter to restore map personality Playtesting raised a real complaint unrelated to the performance work in recent commits: the new generators feel like they've lost personality, with resources reading as small guaranteed blobs stuck next to bases instead of a map with its own texture. Traced this concretely (MapGeneratorStudy's dump= option, rendered and inspected directly) rather than guessing. Fjord Continent's every resource is a deliberate, counted placement: a home starter kit, fjord bank resources on each side of every fjord, stone/fruit in the untouched core, 0-4 outlier islands, and a shoreline algae band - never a map-wide painting pass. That leaves the whole continent interior between those points bare grass. Rendered, it looks exactly like the complaint: a handful of isolated single-tile dots on an otherwise empty landmass, next to Swamp or River's rich, varied, noise-painted texture. Lattice and Maze already solve the identical problem with scatterResources - a light map-wide clump scatter - called before either generator's own guaranteed per-team placements, so a guarantee is placed after and can never be buried or bumped by the ambient layer. Fjord now does the same, at a modest density (wood/corn/stone; algae is left at zero since the shoreline band a few steps later already places it with a shape tuned to the coastline, and scattering more over open water would just fight that). This is a real, deliberate change to how the generator plays, not only its output - the four generators whose colony placement changed earlier in this PR got the same treatment, and the same reasoning applies here. Revision moves to 2. Contested Commons' resource layout was inspected the same way and left alone: its solid, sharply-bordered zones are an explicit, already-reasoned design in its own code ("real fields...not a light dusting", with zones deliberately never bordering a same-role neighbor) - a different design choice, not the same gap. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. Generation succeeds at realistic team counts (1, 2, 4, 8 - 0 failures across 20+ seeds each). At 12 teams - already a barely-supported configuration before this change, failing 18 of 20 seeds on "no swarm footprint fits inside the home region" with no scatter at all - it now fails all 20; a real but marginal effect on a configuration that was not reliable to begin with. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Populate Map::Tile::fertility during generation, not just on load or in the editor Playing raised a real question: why does the in-game fertility overlay show nothing on a freshly generated map? Traced to the source instead of assumed. OverlayArea::compute's Fertility branch (src/OverlayAreas.cpp) only ever copies whatever is already sitting in Map::Tile::fertility - it never computes anything itself. The only two things that ever populate that field are FertilityCalculator (the map editor's "compute fertility" checkbox, and Game_io.cpp's load-time migration for save files older than format version 63, which the comment there describes as "did not have fertility computed with the map, but computed it live") and, now, scoreStarts. Neither of the first two runs as part of generation, so every map that comes out of any of the 13 generators - every custom or lobby game - starts with an all-zero fertility field. This predates and is independent of the recent switch to computing fertility via Fertility::Field: it dates back to whenever version-63 saves started shipping fertility baked into the file instead of computing it live, and generation was simply never updated to do the same. scoreStarts already builds a full Fertility::Field for every roll, purely to score colonies against (see the previous fertility-related commits in this branch). Stamping that same field into the map's own tiles - the same clamp-to-Uint16 FertilityCalculator uses - is a byproduct of work already being paid for, not new work, and scoreStarts is the only point in the generation pipeline that already has a finished map (colonies built, resources final) to stamp it onto. Takes Game& instead of const Game& to do this; its only two callers (GenerationService::generate, MapGeneratorStudy) already held a non-const reference, so this needed no other changes. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each. Confirmed directly (a temporary counter in the profiling driver, since nothing else exposes this) that a freshly generated map now carries a non-trivial fertilityMaximum and real per-tile values across every generator checked, where it was unconditionally zero before. Measured no change outside this machine's run-to-run noise on Lattice, Maze or Swamp - the write is a single O(tile count) pass over data the scoring loop already computed, not a new computation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Fix a generation-failure regression in Fjord's ambient scatter, and scatter along fjord banks too The previous commit's ambient scatter (scatterResources, called right after controlSand - before any team anchor point, home area or swarm existed) was verified for generation success at the time, but not thoroughly enough: a larger seed sample at 8 teams turned up 3 of 20 seeds failing "no swarm footprint fits inside the home region" that succeeded without the scatter, confirmed by direct before/after comparison on the same seeds. isResourceAllowed refuses any tile a building or unit already occupies, but at the point the scatter ran, none existed yet, so an early scatter clump could claim the one tile a colony's swarm footprint needed with nothing downstream able to tell. Fix: move the identical scatterResources call to run after every team's swarm and workers are already placed, right before the fjord bank resources (which already had to run last for the same class of reason). This changes nothing about what the scatter places - setResource always overwrites regardless of order, so a scatter clump can now cosmetically overwrite one tile of an already-placed home-kit deposit, the same low-stakes trade already accepted in the other direction (a bank deposit overwriting a scatter tile). Re-verified on the original 3 failing seeds (now 0) and a fresh sample of 50 seeds each at 1/2/8 teams (0/50, 0/50, 1/50); the one remaining failure reproduces identically - same stage, same error, same hash - on the pre-scatter binary too, confirming it predates and is unrelated to any of today's work. Also extended the fjord banks themselves from two fixed clumps per side into a scattered strip: six more, lighter, best-effort clumps per side along the same bank, mixing corn/wood/stone with a small jitter so the spacing doesn't read as mechanical. Best-effort like the ambient scatter - nothing downstream depends on any single one existing, so a spot that doesn't pan out is simply skipped rather than failing generation. Walking a fjord's edge now reads as following a shoreline with its own economy rather than passing exactly two fixed deposits. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each. Rendered and inspected directly (MapGeneratorStudy's dump= option) to confirm the coastal strip reads as intended alongside the interior scatter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Make scatterResources actually prefer farmable ground, not just legal ground Playing raised a real point: a lot of the resources scatterResources places (Fjord, Lattice and Maze's shared ambient layer) sit far enough from water that Map::growResources - which only regrows a wheat or wood tile near water - will never bring them back once harvested. The candidate search never looked at that: it drew a uniformly random legal tile and used it outright, so a corn or wood clump could as easily land on a one-time find as on real farmland. First attempt required only Fertility::Field::at(x,y) > 0 (computed ungated, since nothing has been harvested yet at this point in generation to gate against). Measured before/after and it changed nothing: on these water-heavy generators a single water tile up to 14 tiles away already contributes a nonzero - if as little as 4 out of a 65536 maximum - weight to nearly every legal tile, so ">0" was already true almost everywhere, fertile-preferring or not. Replaced the fixed bar with best-of-100: score every randomly drawn legal candidate by fertility and keep the highest seen across the same 100-draw budget, rather than stopping at the first past a threshold. A type that doesn't regrow this way (stone, algae, fruit) scores every candidate 0 and so keeps its original "first legal tile found" behaviour exactly. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators (0 failures, 40 seeds each on Fjord/Lattice/Maze, 5 seeds each on the rest). Measured against a meaningful bar (fertility > 8000, the same "genuinely good farmland" reference StartQualityScale uses elsewhere) rather than the uninformative ">0": the fraction of scattered corn/wood clearing it rose from roughly 46% to 68% on Fjord, 40% to 88% on Lattice, and 52% to 96% on Maze. Fjord's revision moves to 3 (already at 2 from the ambient-scatter and coastal-strip work earlier in this branch); Lattice's and Maze's move to 2, their first change since this branch introduced them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Paint ambient wheat/wood as a fertility-threshold band, not independent clumps Playing surfaced two related complaints about scatterResources (Fjord, Lattice, Maze's shared ambient resource layer): the result still read as "clumpy" even after the earlier best-of-100 fertility fix, and a lot of resources were landing far from water where Map::growResources can never regrow them. Root cause of the clumpiness: each clump is an independent random draw at its own center, unlike generateHeightField (Swamp/River/Islands/Crater- Lakes), which paints its resource bands as a histogram threshold over the same Perlin noise field that carved the terrain, so resources follow the same organic contours as the water and grass. scatterResources runs after these generators' terrain is already fixed, so it can't share that field, but it can still take the threshold approach. First attempt thresholded an independent Perlin noise field. This did fix the clumpiness, but measurement showed it quietly undid the fertility fix: an unrelated noise field has no reason to prefer high-fertility ground, so the fraction of scattered corn/wood clearing a meaningful fertility bar fell back to roughly the pre-fix numbers on all three generators. Fixed by thresholding directly on Fertility::Field for corn/wood instead: the field is already smooth and highest near water, so painting its highest-fertility share is simultaneously the coastal-band shape and a genuine preference for farmable ground - one field, not two competing mechanisms. Stone and algae keep the independent noise field, since they have no regrowth rule to prefer. Re-measured against fertility > 8000: 76.5%/73.0% Fjord, 97.9%/98.4% Lattice, 97.1%/98.4% Maze corn/wood - better than the prior best-of-100 numbers, not just recovered. A single global threshold broke visibly on Lattice: its islets are separate landmasses with their own fertility range, so a handful of tiles scoring highest could absorb nearly the whole band, leaving most islets with none - the same "resources aren't balanced" problem this scatter exists to fix, at the scale of one islet instead of one player. computeLandComponents flood- fills the map into connected non-water components once per call, and scatterBand now runs its threshold independently within each component, sized to that component's own share of the candidate pool. A single connected map is one component covering everything (unchanged behaviour); Lattice's islets each get their own proportional coastal fringe now. Lattice and Maze also needed their guaranteed per-team wheat/wood placement reordered ahead of the ambient scatter: the new band-shaped scatter can solidly fill a team's small reserved area with a different resource type before the guarantee gets a turn, failing generation outright. scatterResources already skips any tile that already carries a resource, so running the guarantee first and the ambient scatter after leaves the reservation alone. (Fjord already worked this way from the earlier swarm-footprint fix.) Verified: 0 new failures across 60 seeds x {Lattice, Maze, Fjord}, the extended {teams=2,4,8} x 50-seed sweep (the only two failures found, Fjord seed 70/90 at teams=8, are confirmed pre-existing and unrelated by direct comparison against the pristine pre-ambient-scatter commit), the full 13-generator x 5-seed sanity sweep, and 198/198 cppunit tests. Fjord's revision moves to 4, Lattice's and Maze's to 3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Split ambient farmland placement into where (fertility) vs which crop (noise) Playing turned up a real bug in the fertility-band rewrite: corn and wood were thresholded on the same Fertility::Field back to back (corn claims the highest-fertility band, wood claims the next-highest of what's left), which correctly picks where farmland goes but also, as a side effect, decides which crop goes where using the same field - so corn and wood came out as two concentric rings sorted by distance from water, with wood forming a solid ring that visually hid wheat behind it, instead of sitting side by side the way real farmland does. Fertility should only ever answer "where can farmland go", never "which of the two crops belongs here". Split into two independent steps in the new scatterFarmland: fertility selects one shared candidate region for corn and wood together (per land component, keeping the earlier per-islet fairness fix), and a second, unrelated noise field splits that region into the two crops - sorted by the noise field's own level and sliced into a corn share and a wood share, so which crop lands where varies along the coast instead of stacking into bands by distance from water. Also widened the fertility-selected region to roughly 3x the requested tile count (still per component), so it reaches into lower-but-still-farmable ground instead of a razor-thin ring at only the very highest fertility values, per a direct request for more resources at lower fertility. Separately, widened Fjord's fjord-width control from 2-6 (default 3) to 2-10 (default 4). The wider default measurably increases single-seed generation failures at the most crowded setting (teams=8: 8/100 vs. the pre-existing 2/100 baseline, same "no swarm footprint fits" class as before) but this isn't player-visible: both real entry points into generation roll 5 candidate seeds and keep the best successful one rather than committing to a single explicit seed, so the chance all 5 fail at once is negligible. Settled on default 4 rather than 5 after measuring that 5 pushed the same failure rate to 24/100 - still low risk in practice via the 5-candidate roll, but 4 keeps a comfortable margin without giving up much of the visual effect. Verified: 0 new failures across 60 seeds x {Lattice, Maze, Fjord}, the extended {teams=2,4,8} x 50-seed sweep (only the same two pre-existing Fjord failures reproduced, confirmed unrelated in the prior commit), the full 13-generator x 5-seed sanity sweep, and 198/198 cppunit tests. Fjord's revision moves to 5, Lattice's and Maze's to 4. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Fix wheat trapped behind a wood wall: finer split scale, per-component slack, reachability backstop A screenshot from actual play caught what the aggregate fertility numbers couldn't: a colony's wood had grown into one continuous, multi-tile-deep wall along the entire visible coastline, with no corn in sight. This is a real reachability bug, not a cosmetic one - resource tiles block ground units, so the wheat behind that wall was genuinely unreachable. Two compounding causes, both fixed: 1. The noise field that splits corn from wood reused the same smoothing factor (24) as the continent-shaping terrain noise, sized for whole-map features. At a player's zoomed-in scale, one crop's patch could easily span the entire visible screen - corn and wood alternated across the whole map, just not within reach of any one colony. Dropped to 6, sized for a working area instead of a continent. 2. The fertility-selected region's kWiden multiplier was checked against the map-wide candidate total, not the land component actually being painted, so a small Lattice islet's *entire* eligible pool could be absorbed by the widened region, leaving no slack to route around it. Confirmed by re-running the exact regression seeds against the prior commit: the same tiny islets had both wheat and wood reachable before this session's widening, and lost one or the other after it. Capped the widened region at 2/3 of each component's own pool, so every landmass keeps at least a third of its eligible ground unclaimed regardless of size. As a second, independent backstop, Fjord and Lattice now call guaranteeStartingResources after all resource placement - the same reachability re-check RuggedArchipelago and ShatteredCoast already rely on for this exact class of problem, clearing exactly the wall tiles responsible for a cramped pocket before topping up whichever resource is still out of range. Maze deliberately does not get this call: its maze walls are themselves stone resource tiles by design, and the same wall-clearing logic can't distinguish an intentional corridor wall from an incidental one. Verified with a new check beyond generation success: MapGeneratorStudy's quality mode reports each colony's actual wheat/wood distance (-1 when unreachable) via the same flood scoreStarts already computes. Across 60 seeds x {Lattice, Maze, Fjord} (720 colonies), unreachable cases dropped from 7 to 3, and the 3 remaining are byte-for-byte identical to the pre-session baseline on the same seeds (catchmentTiles=4 - a genuinely tiny, isolated Maze room with no larger landmass behind a wall to reveal, which guaranteeStartingResources correctly declines to force). Fjord specifically, the generator in the screenshot, went from a visible wall to 0 unreachable colonies across the sweep. Also re-verified the standard suite: 0 failures across the 60-seed x 3-generator sweep, the {teams=2,4,8} x 50-seed sweep (same class of pre-existing fjord-width failures as before, none new), the 13-generator sanity sweep, and 198/198 cppunit. Fjord's revision moves to 6, Lattice's and Maze's to 5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Give Fjord a central lake, richer core resources, and fix outlier islands Three related requests plus one genuine bug found along the way. A central lake: after the fjords are carved, a circle in shape-space (lake-size percent of coreR, 0 disables it) becomes the map's shared centerpiece, with a sandy no-man's-land ring around it (wider than controlSand's own coastal fringe), several stone clumps and one clump of every fruit type in the resource ring beyond the sand, and algae in the lake itself. coreR widened from 0.19 to 0.23 of the continent radius to make room for all of this without shortening the fjords. Whether the fjords actually open into the lake is a real topology choice, not a cosmetic one: doing so makes every peninsula water-isolated from its neighbors (boats required to reach anyone), where stopping short behind a solid land ring keeps today's mutual land connectivity. Asked directly rather than guessed at - the answer was to keep both available as a lake-connected control (default off), not pick one permanently. The global cross-team connectivity check only runs when the land ring is expected to exist; lake-connected maps are supposed to fail that specific check by design, and each peninsula's own viability is still verified independently further down regardless. Separately, a real bug: outlier resource islands were going unplaced almost every roll, at every map size - not just small ones, per a direct report. The island annulus's inner bound summed every noise harmonic's amplitude as if all four peaked at the same angle simultaneously, a safe but very pessimistic bound the coastline never actually reaches, and that overshoot doesn't shrink at larger map sizes since both terms scale together. Sampling the coastline's true maximum over 360 angles helped but wasn't enough on its own - the coastline's single worst direction can legitimately approach half the map by itself on plenty of rolls. The real fix: each island's center is already independently randomized, so it never needed to be safe in the coastline's worst direction everywhere, only at its own location - checking each candidate directly against the coastline at its own position (the same per-point technique the open-sea algae placement already uses) replaces one pessimistic global bound with an exact local one. Verified by counting connected landmasses in dump= renders: 10/10 seeds now place islands at 128, 256 and 512 map sizes, versus roughly 1-in-5 before. Verified: 0 failures across 60 seeds at default size, 30 at 256, 30 with lake-connected=1, the {teams=2,4,8} x 50-seed sweep (teams=8 at 2/50, within the already-accepted fjord-width margin), the 13-generator sanity sweep, and 198/198 cppunit. The defaults test also caught two real mistakes before they shipped: lake-size's default (45) wasn't a valid step-10 grid point from minimum 0 (fixed by using step 5), and both new control labels needed entries in data/texts.en.txt and data/texts.keys.txt the same way every other control label already has one. Fjord's revision moves to 7. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Widen resource-islands range, thicken core fruit, center lake algae, rebalance wheat:wood Four follow-ups on the lake/island work. resource-islands' range widened from 0-4 to 0-20 for players who want an island-heavy map - the control itself was already meant to be that flexible, but outlierCount was separately hard-capped at std::min(4, ...), silently discarding anything the control asked for beyond 4. Removed rather than raised, since the per-candidate placement loop is already a safe best-effort search with no fixed ceiling of its own. The core's fruit went from one clump per type to three (kFruitClumpsPerType), so the destination reads as a real grove of each kind rather than a single token tree. Separately, a real bug in the lake's algae: it was drawn from any water tile in the whole lake, shore included, so a clump could anchor right at the water's edge and read as stuck to one side rather than centered - the same "random point instead of a deliberate one" issue as the earlier per-component fertility work, just at lake scale. Fixed by always placing one clump at the lake's exact geometric center (the shape transform's own origin, provably inside the lake for any lake-size > 0) and drawing any bonus clumps only from well inside the shoreline (lakeR - 4, not lakeR). Verified directly with a temporary check, since the dump tool's tile codes don't distinguish algae from plain water: the resource landed on the computed center tile across every seed tested. Last, direct feedback from playing: wood read as overrepresented against wheat. Rebalanced every ambient/bonus layer to 2:1 corn:wood rather than even, leaving every guaranteed placement (Fjord's starter kit and bank guarantees, Lattice/Maze's guaranteed per-team clumps) at 1:1 - those exist for reachability fairness, not feel, and depend on both resources staying equally guaranteed. Fjord's ambient scatter moved from corn=18/wood=18 to corn=24/wood=12 (same total density, reapportioned), its bank-scatter roll distribution from even 3:3:2 to 4:2:2 (corn:wood: stone of 8); Lattice's and Maze's wood control default halved from 50 to 25 against wheat's unchanged 50. Verified the achieved ratio by counting resource tile codes across dump= renders: Lattice and Maze land almost exactly at 2:1 (1.99:1, 1.97:1); Fjord lands at 1.76:1, below 2:1 as expected since its untouched 1:1 guarantees dilute the ambient layer, still a clear shift from the prior 1:1 overall. Verified: 0 failures across 60 seeds each for Fjord/Lattice/Maze, 30 seeds at resource-islands=20, 20 at resource-islands=0, the {teams=2,4,8} x 50-seed sweep (teams=8 at 4/50, same already-accepted margin), the 13-generator sanity sweep, the defaults test, and 198/198 cppunit. Fjord's revision moves to 9, Lattice's and Maze's to 6. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba * Fix fjords not actually reaching the lake in lake-connected mode A screenshot showed the fjords visibly not connecting to the lake - "almost connected but not quite," a thin sand gap sitting right where the water should have merged. First guess: the fjord's target tip radius (fjordInnerR) landed exactly on the lake's boundary circle, so pushed it to lakeR * 0.4 (well inside the lake), reasoning that would force real overlap. Built, verified by flood-filling the water outward from the lake, and it changed nothing - the connected-component size stayed ~35 tiles, the same as the lake alone, never reaching the map edge. The actual cause, found by reading Map::controlSand (MapTerrain.cpp): it converts any water tile with a grass neighbor anywhere in its own 3x3 neighborhood to sand - not a coastal decoration, a channel eraser. The fjord's tip tapers down to tipWidth = 0.65 (a ~1.3-tile diameter) as it approaches its target radius; every tile in a channel that narrow borders grass on both sides, so controlSand sands the entire tip shut regardless of how deep its target radius reaches. Fine for disconnected mode, where that's an intentional dead end against solid land - but it silently closed the one connection lake-connected mode actually needs open. A channel needs a surviving center row not touching grass on either side to remain water at all, which takes a width (radius) of at least ~1.5. Widened tipWidth to max(2.5, mouthWidth * 0.6) specifically in connected mode, comfortably above that floor. The fjordInnerR change stays as a small extra safety margin, though the width fix is what actually matters - the lake is so small relative to the fjord's total length that moving the target radius barely moves the width at the point the centerline actually crosses the lake's boundary. Verified with the same flood-fill check, now correctly: the lake's connected-component water size jumps from ~35 tiles to ~11,000 and reaches the map edge on every seed tested, confirming the outer sea and the lake are now genuinely one connected body via every fjord. Re-verified the standard suite: 0 failures across 60 seeds in disconnected mode, 40 seeds at lake-connected=1, clean at teams 2/4 (0/3…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Random-map defaults now leave more grassy building room while retaining each generator's shape. The lobby, map editor, command-line stress tool and study tooling share one definition of generator names, available controls, ranges, steps and defaults.
Stacked on #237 (
codex/custom-game-ux, 82943d9). The diff contains only the generator follow-up.Changes
Validation
Both baseline cohorts include the same legacy correctness fixes. Placement failures remain: River has 18/1,000 (versus lobby 4 and editor 145), and Shattered Coast 14/1,000 (versus lobby 0 and editor 2), alongside substantially better building room and starting-resource access. Small crowded maps remain a limitation; this is not a full-match balance study.
Generated reports, study data and screenshot files are excluded from the branch tip and final source changes. Existing image embeds reference earlier review commits; future artifacts stay outside tracked source.